Path: blob/master/src/packages/next/pages/news/edit/[id].tsx
6034 views
/*1* This file is part of CoCalc: Copyright © 2023 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import {6Alert,7Breadcrumb,8Button,9Checkbox,10Col,11DatePicker,12Divider,13Form,14Input,15Layout,16Row,17Select,18Space,19} from "antd";20import dayjs from "dayjs";21import { GetServerSidePropsContext } from "next";22import { useRouter } from "next/router";23import { useEffect, useState, type JSX } from "react";2425import { getNewsItem } from "@cocalc/database/postgres/news";26import { Icon } from "@cocalc/frontend/components/icon";27import { capitalize } from "@cocalc/util/misc";28import { slugURL } from "@cocalc/util/news";29import {30CHANNELS,31CHANNELS_DESCRIPTIONS,32Channel,33NewsItem,34} from "@cocalc/util/types/news";35import Footer from "components/landing/footer";36import Head from "components/landing/head";37import Header from "components/landing/header";38import { Paragraph, Title } from "components/misc";39import A from "components/misc/A";40import { News } from "components/news/news";41import Loading from "components/share/loading";42import apiPost from "lib/api/post";43import { MAX_WIDTH, NOT_FOUND } from "lib/config";44import { Customize, CustomizeType } from "lib/customize";45import useProfile from "lib/hooks/profile";46import { extractID } from "lib/news";47import withCustomize from "lib/with-customize";4849interface Props {50customize: CustomizeType;51news?: NewsItem;52}5354type NewsTypeForm = Omit<NewsItem, "date" | "until"> & { date: dayjs.Dayjs; until?: dayjs.Dayjs };5556export default function EditNews(props: Props) {57const { customize, news } = props;58const router = useRouter();5960const id = news?.id; // this is set once, and never changes61const isNew = id == null;62const { siteName } = customize;63const profile = useProfile({ noCache: true });64const isAdmin = profile?.is_admin === true;6566const [form] = Form.useForm();6768const date: dayjs.Dayjs =69typeof news?.date === "number" ? dayjs.unix(news.date) : dayjs();70const until: dayjs.Dayjs | undefined =71typeof news?.until === "number" ? dayjs.unix(news.until) : undefined;7273const init: NewsTypeForm =74news != null75? { ...news, tags: news.tags ?? [], date, until }76: {77hide: false,78title: "",79text: "",80url: "",81tags: [],82channel: "feature",83date: dayjs(),84until: undefined,85};8687const [data, setData] = useState<NewsTypeForm>(init);8889const [error, setError] = useState<string>("");90const [saving, setSaving] = useState<boolean>(false);91const [invalid, setInvalid] = useState<boolean>(false);92const [saved, setSaved] = useState<number | null>(null);9394useEffect(() => {95form.setFieldsValue(data);9697// If we're creating a new item, set the channel from URL params (if such a param exists).98// This is used when creating a new event from the events page.99//100if (isNew) {101const { channel } = router.query;102if (103typeof channel === "string" &&104CHANNELS.includes(channel as Channel)105) {106form.setFieldValue("channel", channel);107}108}109110form.validateFields();111}, [data]);112113async function save() {114setSaving(true);115try {116// send data, but convert date and until fields to epoch seconds117const next = {118...data,119id,120date: data.date.unix(),121until: data.until?.unix()122};123const { channel } = data;124const ret = await apiPost("/news/edit", next);125if (ret == null || ret.id == null) {126throw Error("Problem saving news item – no id returned.");127}128if (channel === "event") {129router.push("/about/events", undefined, { scroll: false });130} else {131router.push(132slugURL({133...data,134...ret,135}),136undefined,137{ scroll: false },138);139}140// this signals to the user that the save was successful141setSaved(ret.id);142} catch (err) {143setError(err.message);144} finally {145setSaving(false);146setError("");147}148}149150function renderSaved() {151if (saving || saved == null) return;152return (153<Alert154banner155type="success"156icon={<Icon name="check" />}157message={158<>159<A href={slugURL({ ...data, id })}>Saved News id={saved}</A>.160</>161}162/>163);164}165166function explainChannel(channel: Channel): JSX.Element | string {167switch (channel) {168case "feature":169return "Updates, modified features, general news, etc. The default category for all news.";170case "announcement":171return "Use this rarely, only once or twice a month.";172case "about":173return "This is the meta-level category.";174case "event":175return (176"Let users know about upcoming company/conference events. These events are ONLY" +177" shown in the About page and are filtered from normal news views."178);179default:180return CHANNELS_DESCRIPTIONS[channel];181}182}183184function updateChannelParam(channel: string) {185const { query } = router;186187router.replace(188{189query: {190...query,191channel,192},193},194undefined,195{ shallow: true, scroll: false },196);197}198199function edit() {200return (201<>202<Title level={2}>203{isNew ? "Create New News" : `Edit News #${id}`}204</Title>205<Form206form={form}207initialValues={data}208labelCol={{ span: 4 }}209wrapperCol={{ span: 20 }}210onValuesChange={(_, allValues) => {211setSaved(null);212setData(allValues);213}}214onFieldsChange={() =>215setInvalid(form.getFieldsError().some((e) => e.errors.length > 0))216}217>218<Form.Item219label="Title"220name="title"221rules={[{ required: true, min: 1 }]}222>223<Input />224</Form.Item>225<Form.Item226label="Date"227name="date"228rules={[{ required: true }]}229extra={`Future dates will not be shown until it is time. This date is in the ${230form.getFieldValue("date")?.isAfter(dayjs()) ? "future" : "past"231}.`}232>233<DatePicker changeOnBlur showTime={true} allowClear={false} />234</Form.Item>235<Form.Item236label="Until"237name="until"238rules={[{ required: false }]}239extra="Optional expiration date - news item will not be shown after this date. Leave empty to never expire."240>241<DatePicker changeOnBlur showTime={true} allowClear={true} />242</Form.Item>243<Form.Item244label="Channel"245name="channel"246rules={[{ required: true }]}247extra={explainChannel(data.channel)}248>249<Select onSelect={(value) => updateChannelParam(value)}>250{CHANNELS.map((ch) => {251return (252<Select.Option value={ch} key={ch}>253{capitalize(ch)} ({CHANNELS_DESCRIPTIONS[ch]})254</Select.Option>255);256})}257</Select>258</Form.Item>259<Form.Item260label="Tags"261name="tags"262rules={[{ required: false }]}263extra={`Common ones are "jupyter", "latex" or "sagemath". Don't set too many, one is usually good enough.`}264>265<Select mode="tags" style={{ width: "100%" }} />266</Form.Item>267<Form.Item268label="Message"269name="text"270extra={`Markdown is supported. Insert images via , e.g. shared on ${siteName} itself.`}271rules={[{ required: true, min: 1 }]}272>273<Input.TextArea274rows={10}275style={{ fontFamily: "monospace", fontSize: "90%" }}276/>277</Form.Item>278<Form.Item279label="URL"280name="url"281rules={[{ required: false, type: "url" }]}282extra={`optional, external URL, will be shown as "Read more" link.`}283>284<Input allowClear />285</Form.Item>286<Form.Item label="Hide" name="hide" valuePropName="checked">287<Checkbox>If checked, will not be shown publicly.</Checkbox>288</Form.Item>289</Form>290<Divider />291<Row gutter={30}>292<Col span={16}>293<Paragraph>294<News news={{ ...data, id, date: data.date.unix(), until: data.until?.unix() }} />295</Paragraph>296</Col>297<Col span={8}>298<Space direction="horizontal" size="large">299<Button300onClick={save}301disabled={saving || saved != null || invalid}302type="primary"303>304{isNew ? "Create" : "Save"}305</Button>306<Button href={slugURL({ ...data, id })}>Cancel</Button>307</Space>308<Divider type="horizontal" />309{error && <Alert type="error" message={error} />}310{saving && <Loading />}311{renderSaved()}312</Col>313</Row>314</>315);316}317318function content() {319if (profile == null) return <Loading />;320if (!isAdmin) {321return <Alert type="error" message="Not authorized" />;322}323return edit();324}325326const title = `${siteName} / Edit News / ${isNew ? "new" : `${id}`}`;327328const items = [329{ key: "/", title: <A href="/">{siteName}</A> },330{ key: "/news", title: <A href="/news">News</A> },331{ key: "new", title: isNew ? "Create New" : `Edit #${id}` },332];333334return (335<Customize value={customize}>336<Head title={title} />337<Layout>338<Header />339<Layout.Content340style={{341backgroundColor: "white",342}}343>344<div345style={{346minHeight: "75vh",347maxWidth: MAX_WIDTH,348padding: "30px 15px",349margin: "0 auto",350}}351>352<Breadcrumb style={{ margin: "30px 0" }} items={items} />353{content()}354</div>355<Footer />356</Layout.Content>357</Layout>358</Customize>359);360}361362export async function getServerSideProps(context: GetServerSidePropsContext) {363const { query } = context;364const { id: idQ } = query;365366if (idQ === "new") {367return await withCustomize({ context, props: { news: null } });368}369370const id = extractID(idQ);371if (id != null) {372try {373// false: bypasses cache374const news = await getNewsItem(id, false);375if (news != null) {376return await withCustomize({ context, props: { news } });377}378} catch (err) {379console.log("Error loading news item", err.message);380}381}382383return NOT_FOUND;384}385386387